page.tsx 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. import '../../style.scss';
  2. import { cache } from 'react';
  3. import { Metadata } from 'next';
  4. import Link from 'next/link';
  5. import { notFound } from 'next/navigation';
  6. import { fetchBondDetail } from '@/lib/api/seibro';
  7. import { codeLabel, formatAmountCompact, formatDecimal, formatRatePercent, formatShares, dateOrDash } from '@/lib/utils/market';
  8. import {
  9. BOND_OPTION_LABEL,
  10. BOND_RATE_CHG_LABEL,
  11. BOND_GUARANTEE_LABEL,
  12. BOND_RANK_LABEL,
  13. BOND_INT_PAY_WAY_LABEL,
  14. BOND_INT_CYCLE_LABEL,
  15. BOND_INT_TIMING_LABEL,
  16. BOND_PARTICUL_KIND_LABEL,
  17. BOND_SECN_KACD_LABEL
  18. } from '@/types/seibro';
  19. type Props = {
  20. params: Promise<{
  21. isin: string;
  22. }>;
  23. };
  24. // generateMetadata + 본문 이중 호출 방지 (요청 단위 dedupe)
  25. const getDetail = cache(async (isin: string) => {
  26. return await fetchBondDetail(isin);
  27. });
  28. export async function generateMetadata({ params }: Props): Promise<Metadata>
  29. {
  30. const { isin } = await params;
  31. const res = await getDetail(isin);
  32. const name = res.success && res.data?.master?.name ? res.data.master.name : isin;
  33. return {
  34. title: `${name} 채권 상세 — 개미투자`,
  35. description: `${name}(${isin}) 채권 상세 — 만기·표면이자율·신용등급·이자지급·종가/YTM (SEIBro)`
  36. };
  37. }
  38. export default async function BondDetailPage({ params }: Props)
  39. {
  40. const { isin } = await params;
  41. const res = await getDetail(isin);
  42. if (!res.success && res.status === 404) {
  43. return notFound();
  44. }
  45. if (!res.success || !res.data) {
  46. return (
  47. <div className='market'>
  48. <Link href='/market/bonds' className='market__detail-back'>← 채권 목록</Link>
  49. <div className='market__error' role='alert'>
  50. <strong>채권 정보를 불러오지 못했습니다.</strong>
  51. 잠시 후 다시 시도해 주세요.
  52. </div>
  53. </div>
  54. );
  55. }
  56. const detail = res.data;
  57. const master = detail.master;
  58. const interest = detail.interest;
  59. const market = detail.market;
  60. const name = master?.name ?? detail.isin;
  61. const grades = [
  62. { agency: 'KIS', grade: master?.kisGrade },
  63. { agency: 'NICE', grade: master?.niceGrade },
  64. { agency: 'SCI', grade: master?.sciGrade },
  65. { agency: 'KR', grade: master?.krGrade }
  66. ].filter(c => c.grade);
  67. return (
  68. <div className='market'>
  69. <Link href='/market/bonds' className='market__detail-back'>← 채권 목록</Link>
  70. <div className='market__head'>
  71. <h1 className='market__title'>{name}</h1>
  72. <span className='market__basis'>{detail.isin} · 채권 상세 (SEIBro)</span>
  73. </div>
  74. {/* KRX 최신 시세 */}
  75. {market && (
  76. <div className='market__spec-card'>
  77. <h2 className='market__spec-card-title'>최신 시세 <span className='market__basis'>({market.tradeDate} 기준)</span></h2>
  78. <dl className='market__spec-grid'>
  79. <div className='market__spec-item'>
  80. <dt>종가</dt>
  81. <dd>{formatDecimal(market.close)}</dd>
  82. </div>
  83. <div className='market__spec-item'>
  84. <dt>만기수익률(YTM)</dt>
  85. <dd>{formatRatePercent(market.yieldToMaturity, 3)}</dd>
  86. </div>
  87. <div className='market__spec-item'>
  88. <dt>거래량</dt>
  89. <dd>{formatShares(market.volume)}</dd>
  90. </div>
  91. </dl>
  92. </div>
  93. )}
  94. {/* 채권 마스터 */}
  95. {master ? (
  96. <div className='market__spec-card'>
  97. <h2 className='market__spec-card-title'>발행 정보</h2>
  98. <dl className='market__spec-grid'>
  99. <div className='market__spec-item'>
  100. <dt>종목종류</dt>
  101. <dd>{codeLabel(master.secnKacd, BOND_SECN_KACD_LABEL)}</dd>
  102. </div>
  103. <div className='market__spec-item'>
  104. <dt>발행일</dt>
  105. <dd>{dateOrDash(master.issueDate)}</dd>
  106. </div>
  107. <div className='market__spec-item'>
  108. <dt>만기일</dt>
  109. <dd>{dateOrDash(master.maturityDate)}</dd>
  110. </div>
  111. <div className='market__spec-item'>
  112. <dt>표면이자율</dt>
  113. <dd>{formatRatePercent(master.couponRate, 3)}</dd>
  114. </div>
  115. <div className='market__spec-item'>
  116. <dt>통화</dt>
  117. <dd>{master.currency ?? '—'}</dd>
  118. </div>
  119. <div className='market__spec-item'>
  120. <dt>최초발행금액</dt>
  121. <dd>{formatAmountCompact(master.firstIssueAmount)}</dd>
  122. </div>
  123. <div className='market__spec-item'>
  124. <dt>발행잔액</dt>
  125. <dd>{formatAmountCompact(master.issueRemaining)}</dd>
  126. </div>
  127. <div className='market__spec-item'>
  128. <dt>금리유형</dt>
  129. <dd>{codeLabel(master.rateChangeTpcd, BOND_RATE_CHG_LABEL)}</dd>
  130. </div>
  131. <div className='market__spec-item'>
  132. <dt>보증</dt>
  133. <dd>{codeLabel(master.guaranteeTpcd, BOND_GUARANTEE_LABEL)}</dd>
  134. </div>
  135. <div className='market__spec-item'>
  136. <dt>순위</dt>
  137. <dd>{codeLabel(master.rankTpcd, BOND_RANK_LABEL)}</dd>
  138. </div>
  139. <div className='market__spec-item'>
  140. <dt>이자지급방법</dt>
  141. <dd>{codeLabel(master.intPayWayTpcd, BOND_INT_PAY_WAY_LABEL)}</dd>
  142. </div>
  143. <div className='market__spec-item'>
  144. <dt>옵션</dt>
  145. <dd>{codeLabel(master.optionTpcd, BOND_OPTION_LABEL)}</dd>
  146. </div>
  147. <div className='market__spec-item'>
  148. <dt>특이종류</dt>
  149. <dd>{codeLabel(master.particularBondKindTpcd, BOND_PARTICUL_KIND_LABEL)}</dd>
  150. </div>
  151. <div className='market__spec-item'>
  152. <dt>조기상환</dt>
  153. <dd>{master.forcedEarlyRedemption === null ? '—' : master.forcedEarlyRedemption ? '있음' : '없음'}</dd>
  154. </div>
  155. <div className='market__spec-item'>
  156. <dt>전자증권</dt>
  157. <dd>{master.isElectronicSecurity === null ? '—' : master.isElectronicSecurity ? '전자' : '예탁'}</dd>
  158. </div>
  159. </dl>
  160. {grades.length > 0 && (
  161. <div className='market__grades'>
  162. {grades.map(c => (
  163. <div key={c.agency} className='market__grade'>
  164. <span>{c.agency}</span>
  165. <strong>{c.grade}</strong>
  166. </div>
  167. ))}
  168. </div>
  169. )}
  170. </div>
  171. ) : (
  172. <div className='market__empty'>채권 발행 마스터 정보가 없습니다 (KRX 시세만 수집됨).</div>
  173. )}
  174. {/* 이자지급 정보 */}
  175. {interest && (
  176. <div className='market__spec-card'>
  177. <h2 className='market__spec-card-title'>이자지급</h2>
  178. <dl className='market__spec-grid'>
  179. <div className='market__spec-item'>
  180. <dt>표면이자율</dt>
  181. <dd>{formatRatePercent(interest.couponRate, 3)}</dd>
  182. </div>
  183. <div className='market__spec-item'>
  184. <dt>지급방법</dt>
  185. <dd>{codeLabel(interest.intPayWayTpcd, BOND_INT_PAY_WAY_LABEL)}</dd>
  186. </div>
  187. <div className='market__spec-item'>
  188. <dt>지급주기</dt>
  189. <dd>{interest.cycleTerms ?? '—'}{interest.cycleTpcd ? codeLabel(interest.cycleTpcd, BOND_INT_CYCLE_LABEL) : ''}</dd>
  190. </div>
  191. <div className='market__spec-item'>
  192. <dt>지급시기</dt>
  193. <dd>{codeLabel(interest.payTimingTpcd, BOND_INT_TIMING_LABEL)}</dd>
  194. </div>
  195. <div className='market__spec-item'>
  196. <dt>직전 이표일</dt>
  197. <dd>{dateOrDash(interest.beforeCouponDate)}</dd>
  198. </div>
  199. <div className='market__spec-item'>
  200. <dt>차기 이표일</dt>
  201. <dd>{dateOrDash(interest.afterCouponDate)}</dd>
  202. </div>
  203. </dl>
  204. </div>
  205. )}
  206. </div>
  207. );
  208. }